#
# TAP.py - TAP parser
#
# A pyparsing parser to process the output of the Perl
#   "Test Anything Protocol"
#   (https://metacpan.org/pod/release/PETDANCE/TAP-1.00/TAP.pm)
#
# TAP output lines are preceded or followed by a test number range:
#   1..n
# with 'n' TAP output lines.
#
# The general format of a TAP output line is:
#   ok/not ok (required)
#   Test number (recommended)
#   Description (recommended)
#   Directive (only when necessary)
#
# A TAP output line may also indicate abort of the test suit with the line:
#   Bail out!
# optionally followed by a reason for bailing
#
# Copyright 2008, by Paul McGuire
#

from pyparsing import (
    ParserElement,
    LineEnd,
    Optional,
    Word,
    nums,
    Regex,
    Literal,
    CaselessLiteral,
    Group,
    OneOrMore,
    Suppress,
    rest_of_line,
    FollowedBy,
    empty,
    autoname_elements,
)

__all__ = ["tapOutputParser", "TAPTest", "TAPSummary"]

# newlines are significant whitespace, so set default skippable
# whitespace to just spaces and tabs
ParserElement.set_default_whitespace_chars(" \t")
NL = LineEnd().suppress()

integer = Word(nums)
plan = "1.." + integer("ubound")

OK, NOT_OK = map(Literal, ["ok", "not ok"])
testStatus = OK | NOT_OK

description = Regex(r"[^#\n]+")
description.set_parse_action(lambda t: t[0].lstrip("- "))

TODO, SKIP = map(CaselessLiteral, "TODO SKIP".split())
directive = Group(
    Suppress("#")
    + (
        TODO + rest_of_line
        | FollowedBy(SKIP) + rest_of_line.copy().set_parse_action(lambda t: ["SKIP", t[0]])
    )
)

commentLine = Suppress("#") + empty + rest_of_line

testLine = Group(
    Optional(OneOrMore(commentLine + NL))("comments")
    + testStatus("passed")
    + Optional(integer)("testNumber")
    + Optional(description)("description")
    + Optional(directive)("directive")
)
bailLine = Group(Literal("Bail out!")("BAIL") + empty + Optional(rest_of_line)("reason"))

tapOutputParser = Optional(Group(plan)("plan") + NL) & Group(
    OneOrMore((testLine | bailLine) + NL)
)("tests")

autoname_elements()


class TAPTest:
    def __init__(self, results):
        self.num = results.testNumber
        self.passed = results.passed == "ok"
        self.skipped = self.todo = False
        if results.directive:
            self.skipped = results.directive[0][0] == "SKIP"
            self.todo = results.directive[0][0] == "TODO"

    @classmethod
    def bailedTest(cls, num):
        ret = TAPTest(empty.parse_string(""))
        ret.num = num
        ret.skipped = True
        return ret


class TAPSummary:
    def __init__(self, results):
        self.passedTests = []
        self.failedTests = []
        self.skippedTests = []
        self.todoTests = []
        self.bonusTests = []
        self.bail = False
        if results.plan:
            expected = list(range(1, int(results.plan.ubound) + 1))
        else:
            expected = list(range(1, len(results.tests) + 1))

        for i, res in enumerate(results.tests):
            # test for bail out
            if res.BAIL:
                # ~ print "Test suite aborted: " + res.reason
                # ~ self.failedTests += expected[i:]
                self.bail = True
                self.skippedTests += [TAPTest.bailedTest(ii) for ii in expected[i:]]
                self.bailReason = res.reason
                break

            # ~ print res.dump()
            testnum = i + 1
            if res.testNumber != "":
                if testnum != int(res.testNumber):
                    print("ERROR! test %(testNumber)s out of sequence" % res)
                testnum = int(res.testNumber)
            res["testNumber"] = testnum

            test = TAPTest(res)
            if test.passed:
                self.passedTests.append(test)
            else:
                self.failedTests.append(test)
            if test.skipped:
                self.skippedTests.append(test)
            if test.todo:
                self.todoTests.append(test)
            if test.todo and test.passed:
                self.bonusTests.append(test)

        self.passedSuite = not self.bail and (
            set(self.failedTests) - set(self.todoTests) == set()
        )

    def summary(self, showPassed=False, showAll=False):
        testListStr = lambda tl: "[" + ",".join(str(t.num) for t in tl) + "]"
        summaryText = []
        if showPassed or showAll:
            summaryText.append(f"PASSED: {testListStr(self.passedTests)}")
        if self.failedTests or showAll:
            summaryText.append(f"FAILED: {testListStr(self.failedTests)}")
        if self.skippedTests or showAll:
            summaryText.append(f"SKIPPED: {testListStr(self.skippedTests)}")
        if self.todoTests or showAll:
            summaryText.append(f"TODO: {testListStr(self.todoTests)}")
        if self.bonusTests or showAll:
            summaryText.append(f"BONUS: {testListStr(self.bonusTests)}")
        if self.passedSuite:
            summaryText.append("PASSED")
        else:
            summaryText.append("FAILED")
        return "\n".join(summaryText)


# create TAPSummary objects from tapOutput parsed results, by setting
# class as parse action
tapOutputParser.set_parse_action(TAPSummary)


def main():
    import contextlib

    with contextlib.suppress(Exception):
        tapOutputParser.create_diagram("TAP_diagram.html", vertical=3)

    test1 = """\
        1..4
        ok 1 - Input file opened
        not ok 2 - First line of the input valid
        ok 3 - Read the rest of the file
        not ok 4 - Summarized correctly # TODO Not written yet
        """
    test2 = """\
        ok 1
        not ok 2 some description # TODO with a directive
        ok 3 a description only, no directive
        ok 4 # TODO directive only
        ok a description only, no directive
        ok # Skipped only a directive, no description
        ok
        """
    test3 = """\
        ok - created Board
        ok
        ok
        not ok
        ok
        ok
        ok
        ok
        # +------+------+------+------+
        # |      |16G   |      |05C   |
        # |      |G N C |      |C C G |
        # |      |  G   |      |  C  +|
        # +------+------+------+------+
        # |10C   |01G   |      |03C   |
        # |R N G |G A G |      |C C C |
        # |  R   |  G   |      |  C  +|
        # +------+------+------+------+
        # |      |01G   |17C   |00C   |
        # |      |G A G |G N R |R N R |
        # |      |  G   |  R   |  G   |
        # +------+------+------+------+
        ok - board has 7 tiles + starter tile
        1..9
        """
    test4 = """\
        1..4
        ok 1 - Creating test program
        ok 2 - Test program runs, no error
        not ok 3 - infinite loop # TODO halting problem unsolved
        not ok 4 - infinite loop 2 # TODO halting problem unsolved
        """
    test5 = """\
        1..20
        ok - database handle
        not ok - failed database login
        Bail out! Couldn't connect to database.
        """
    test6 = """\
        ok 1 - retrieving servers from the database
        # need to ping 6 servers
        ok 2 - pinged diamond
        ok 3 - pinged ruby
        not ok 4 - pinged sapphire
        ok 5 - pinged onyx
        not ok 6 - pinged quartz
        ok 7 - pinged gold
        1..7
        """

    for test in (test1, test2, test3, test4, test5, test6):
        print(test)
        tapResult = tapOutputParser.parse_string(test)[0]
        print(tapResult.summary(showAll=True))
        print()


if __name__ == "__main__":
    main()
